aws-jwt-verifyでCognitoのアクセストークン検証をHonoのMiddlewareとして実装してみた

aws-jwt-verifyでCognitoのアクセストークン検証をHonoのMiddlewareとして実装してみた

Clock Icon2024.12.29

はじめに

Honoが好きなコンサル部の神野です。今日はaws-jwt-verifyについて紹介させてください。

aws-jwt-verify

皆さん、aws-jwt-verifyをご存知ですか? 私はふと、Cognitoのトークン検証について下記公式ドキュメントを調べていたところ、トークン検証に特化したライブラリがあるんだと知って喜びました!

https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html

公式ドキュメントからの引用

In a Node.js app, AWS recommends the aws-jwt-verify library to validate the parameters in the token that your user passes to your app... 引用部分は省略

Node.jsアプリケーションならaws-jwt-verifyを使うことを推奨すると書いてありますね!ますます気になります。

ライブラリの特徴

aws-jwt-verifyは、CognitoやOIDC互換のIdPが発行するトークンの検証をよしなにやってくれるライブラリです。

主な特徴

  • トークンの署名検証や有効期限チェックを実行
  • JWKSエンドポイントからの公開鍵取得をキャッシュで効率化
  • JWKSエンドポイントへのアクセスレート制限機能により過度なアクセスを防止

上記機能により、自前で検証機構を実装するよりも簡単に作れそうです。
引用したAWSの公式ドキュメントにも記載があるよう、Node.jsアプリケーションではこのライブラリの使用を推奨しているのも魅力的ですね!

今回の検証について

レポジトリを見ると、expressfastifyなどのWebフレームワークの実装例が紹介されていますが、せっかくなのでHono好きな私としては、HonoのMiddlewareとしてaws-jwt-verifyをCognitoのアクセストークン検証機構に組み込んで使ってみたいと思います!

https://github.com/awslabs/aws-jwt-verify?tab=readme-ov-file#cloudfront-lambdaedge

前提

本記事で使用する環境およびライブラリのバージョンは下記のとおりです。

実行環境

  • Node.js: v20.16.0

使用パッケージ

  • @hono/node-server: 1.13.7
  • aws-jwt-verify: 4.0.1
  • hono: 4.6.15

準備

Hono環境構築

任意のフォルダで下記コマンドを実行しHonoの環境を構築します。今回はnodejsおよび依存関係のパッケージマネージャーはnpmを使用します。

実行コマンド
npm create hono@latest my-app

create-hono version 0.14.3
✔ Using target directory … my-app
? Which template do you want to use? nodejs
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd my-app

# 作成が完了したらプロジェクトへ階層を移動
cd my-app

今回使用するライブラリaws-jwt-verifyもインストールしておきます。

ライブラリインストールコマンド
npm install aws-jwt-verify

これで一旦Honoの実行環境は作成完了で、次にCognitoの環境を作成します。

Cognito環境構築

検証用のCognito環境を構築します。下記シェルスクリプトを実行することで、以下の一連の処理を自動で行います。

  1. ユーザープールの作成
  2. アプリケーションクライアントの作成
  3. テストユーザーの登録と確認
  4. アクセストークンの取得

実行前に、以下の変数を環境に合わせて設定してください。

設定する変数の例

入力例
USER_POOL_NAME="任意のユーザープール名"
APP_CLIENT_NAME="任意のクライアントアプリ名"
EMAIL="登録するメールアドレス"
PASSWORD="パスワード(8文字以上、大小文字・数字・記号を含む)"

実行するシェル

create-cognito.shとして下記シェルを作成します。

create-cognito.sh
#!/bin/bash

# 変数設定
USER_POOL_NAME="CognitoUserPool"
APP_CLIENT_NAME="CognitoAppClient"
EMAIL="your-email"
PASSWORD="your-password"

# User Pool の作成
echo "Creating User Pool..."
USER_POOL_ID=$(aws cognito-idp create-user-pool \
    --pool-name "$USER_POOL_NAME" \
    --policies '{"PasswordPolicy":{"MinimumLength":8,"RequireUppercase":true,"RequireLowercase":true,"RequireNumbers":true,"RequireSymbols":true}}' \
    --username-attributes email \
    --query 'UserPool.Id' \
    --output text)

# User Pool Client の作成
# callback-urlsなどは検証のため適当な値を設定しています。
echo "Creating User Pool Client..."
CLIENT_ID=$(aws cognito-idp create-user-pool-client \
    --user-pool-id $USER_POOL_ID \
    --client-name "$APP_CLIENT_NAME" \
    --no-generate-secret \
    --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \
    --supported-identity-providers COGNITO \
    --callback-urls '["http://localhost:3000/callback"]' \
    --logout-urls '["http://localhost:3000/logout"]' \
    --allowed-o-auth-flows code \
    --allowed-o-auth-scopes "email" "openid" "profile" \
    --allowed-o-auth-flows-user-pool-client \
    --query 'UserPoolClient.ClientId' \
    --output text)

# ユーザーの登録
echo "Signing up user..."
aws cognito-idp sign-up \
    --client-id $CLIENT_ID \
    --username $EMAIL \
    --password $PASSWORD

# ユーザーの確認(admin confirm)
echo "Confirming user..."
aws cognito-idp admin-confirm-sign-up \
    --user-pool-id $USER_POOL_ID \
    --username $EMAIL

# 認証とトークンの取得
echo "Authenticating user..."
AUTH_RESULT=$(aws cognito-idp initiate-auth \
    --client-id $CLIENT_ID \
    --auth-flow USER_PASSWORD_AUTH \
    --auth-parameters USERNAME=$EMAIL,PASSWORD=$PASSWORD)

# 結果の表示
echo "Summary:"
echo "User Pool ID: $USER_POOL_ID"
echo "Client ID: $CLIENT_ID"
echo "Access Token: $(echo "$AUTH_RESULT" | jq -r '.AuthenticationResult.AccessToken')"
echo "ID Token: $(echo "$AUTH_RESULT" | jq -r '.AuthenticationResult.IdToken')"

実行結果と必要な情報の取得

まず、スクリプトをCloudShellで実行できるように準備します。以下のいずれかの方法で実行用のシェルスクリプトを作成してください。

  • 方法1: nanoエディタで直接作成
実行コマンド
nano create-cognito.sh
  • 方法2: ローカルでスクリプトファイルを作成し、CloudShellへ転送

作成が完了したら、実行します。

実行コマンド
sh ./create-cognito.sh

実行結果のイメージ

実行結果
Creating User Pool...
Creating User Pool Client...
Signing up user...
Confirming user...
Authenticating user...

Summary:
User Pool ID: ap-northeast-1_zzzz
Client ID: yyyyyy
Access Token: xxx
ID Token: yyy

この実行結果から、以下の3つの値を控えておいてください。後ほどアプリケーションの実装時に使用します。

  • User Pool ID
  • Client ID
  • Access Token

なお、アクセストークンは1時間で有効期限が切れます。期限切れの場合は、以下のコマンドで新しいトークンを取得します。

アクセストークン取得
aws cognito-idp initiate-auth \
    --client-id <作成したクライアントアプリケーションのID> \
    --auth-flow USER_PASSWORD_AUTH \
    --auth-parameters USERNAME=<登録したメールアドレス>,PASSWORD=<登録したパスワード>

補足

シェルで作成したユーザープールおよびクライアントアプリケーションのIDが不明になった場合は画面上から確認できます。

ユーザープールID

CleanShot 2024-12-28 at 23.50.22@2x

アプリケーションクライアントID

CleanShot 2024-12-28 at 23.51.35@2x

実装

src/index.tsaws-jwt-verifyを使用したMiddlewareの実装と、トークンが有効な場合はtoken validを返却する簡単なエンドポイントを実装します。

コード全体
src/index.ts
import { Hono } from "hono";
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { serve } from "@hono/node-server";
import { logger } from "hono/logger";
import { createMiddleware } from "hono/factory";

const app = new Hono();

// JWT Verifierの作成
const verifier = CognitoJwtVerifier.create({
  userPoolId: "<準備セクションで取得したUser Pool ID>",
  tokenUse: "access",
  clientId: "<準備セクションで取得したClient ID>",
});

// JWKSキャッシュの事前読み込み(オプション)
await verifier.hydrate();

// JWT検証のMiddleware
const authMiddleware = createMiddleware(async (c, next) => {
  try {
    const token = c.req.header("authorization");

    if (!token) {
      return c.json(
        {
          message: "Authorization header missing",
        },
        401
      );
    }

    // JWTの検証
    const payload = await verifier.verify(token);
    console.log(payload);

    await next();
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : "Unknown error"
    return c.json(
      {
        message: "Token not valid",
        detail: errorMessage,
      },
      403
    );
  }
});

// Middlewareを適用
app.use(logger());
app.use("/*", authMiddleware);

app.get("/", (c) => {
  return c.json({ message: "token valid!" });
});

serve(app);

要点

aws-jwt-verifyの実装

認証用のクライアントオブジェクトを作って、verifyメソッドを検証します。
verifierクラスのインスタンス作成時には、準備セクションで取得した以下の値を設定します。

  • userPoolId:CognitoユーザープールのプールID
  • clientId:アプリケーションクライアントのID
  • tokenUse:今回はaccess(アクセストークン)を使用(IDトークンも検証可能で、その場合はidを指定する)
aws-jwt-verify実装箇所
const verifier = CognitoJwtVerifier.create({
  userPoolId: "<準備セクションで取得したUser Pool ID>",
  tokenUse: "access",
  clientId: "<準備セクションで取得したClient ID>",
});

// JWKSキャッシュの事前読み込み(オプション)
await verifier.hydrate();

// tokenを検証
await verifier.verify(token)

CognitoJwtVerifier.create()のオプションとしては下記となります。

  • userPoolId(必須): Cognitoユーザープールのプール ID
  • tokenUse(必須): トークンの用途を指定。id(IDトークン)またはaccess(アクセストークン)。
  • clientId(必須): アプリケーションクライアントID。トークンのaud(IDトークン)またはclient_id(アクセストークン)と照合
  • groups(任意): トークンに含まれるcognito:groupsの検証
  • scope(任意): アクセストークンのスコープ検証
  • graceSeconds(任意): トークンの有効期限に対する猶予時間(秒)

今回の実装では基本的な設定であるuserPoolIdtokenUseclientIdのみを使用していますが、より厳密な検証が必要な場合は、groupsscopeなどの追加オプションを活用することができます。

JWKSキャッシュについて

長時間稼働するNode.jsアプリケーション(例:Fargateコンテナ)では、サーバー起動時にJWKSキャッシュを事前に読み込むことで、初回のトークン検証を高速化も可能です。

// キャッシュの事前読み込み
await verifier.hydrate();
  • hydrate()メソッドは、設定された全てのissuerの最新のJWKSを取得しキャッシュします
  • コンテナ起動時など、トラフィックが流れていない待機時間中に実行するのが効果的です
  • Lambda@EdgeやAPI Gateway Lambda Authorizerでは、既存のキャッシュをバイパスしてしまうため、逆にパフォーマンスが低下する可能性があります

なお、レポジトリのReadmeによると、JWKSキャッシュは以下のように動作します。

JWKSキャッシュは最初に1回フェッチされ、その後はキーローテーションの際にのみフェッチされます(キャッシュにまだ存在しないkidを持つJWTが検出された場合)

hydrate()は初期化用途であり、その後のキーローテーションは自動的に処理されます。定期的なキャッシュクリアが必要な場合は、別途スケジュールを設定することも可能です。

// 4時間ごとにキャッシュをクリア
setInterval(
  () => {
    verifier.cacheJwks({ keys: [] }); // 空のJWKSをロードしてキャッシュをクリア
  },
  1000 * 60 * 60 * 4 // 4時間間隔
);

自前でキャッシュやキーローテーション機能を作るのは少し手間なので、ライブラリ側でコントロールしてくれるのはめちゃくちゃ嬉しいですね!!

Middlewareについて

HonoのMiddlewareを使って、トークン検証の処理を実装します。Honoの公式ドキュメントを参考に、以下のような実装を行いました!

Middleware実装箇所
import { createMiddleware } from "hono/factory";

// JWT検証のMiddleware
const authMiddleware = createMiddleware(async (c, next) => {
  try {
    const token = c.req.header("authorization");

    if (!token) {
      return c.json(
        {
          message: "Authorization header missing",
        },
        401
      );
    }

    // JWTの検証
    const payload = await verifier.verify(token);
    console.log(payload);

    await next();
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : "Unknown error"
    return c.json(
      {
        message: "Token not valid",
        detail: errorMessage,
      },
      403
    );
  }
});

// Middlewareを全エンドポイントに適応
app.use("/*", authMiddleware);

作成したMiddlewareはapp.use("/*",authMiddleware)で全エンドポイントに適応し、以下の処理を行っています。

  1. Authorizationヘッダーの値を取得し、トークンが存在しない場合は401エラーを返却
  2. aws-jwt-verifyを使用してトークンを検証、検証に成功した場合はリクエスト先のエンドポイントの処理へ
  3. 検証時にエラーが発生した場合は403エラーを返却

めちゃくちゃシンプルに実装できていいですね・・・!!このようにシンプルに実装できるのはHonoの魅力ですね!

動作確認

まずは下記コマンドでサーバーを起動します。

npm run dev

http://localhost:3000でサーバーが立ち上がったら、まずは準備セクションで取得したアクセストークンをAuthorizationヘッダーに設定して、リクエストを送信してみます!

実行コマンドと結果(正常系)

実行コマンドと結果(正常系)
# 実行コマンド
curl http://localhost:3000 \
  -H "Authorization: <準備セクションで取得したAccess Token>" -i

# 実行結果 
HTTP/1.1 200 OK
content-type: application/json
content-length: 26
Date: Sat, 28 Dec 2024 16:25:12 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"token valid!"}

無事検証成功して、ステータスコード200でtoken valid!が返却されましたね!

補足:payloadについて

また、console.log(payload)で以下のようなペイロード情報が確認できます。

payloadのイメージ
{
  "sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/<User Pool ID>",
  "client_id": "<Client ID>",
  "token_use": "access",
  "scope": "aws.cognito.signin.user.admin",
  "auth_time": 1735402386,
  "exp": 1735405986,
  "iat": 1735402386,
  "username": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

各項目の説明は以下のとおりです。使用される際は必要に応じてご参照ください。

  • sub: ユーザーの一意識別子
  • iss: トークン発行者(Cognito)のURL
  • client_id: アプリケーションクライアントのID
  • token_use: トークンタイプ(access/id)
  • scope: トークンのアクセス範囲
  • auth_time: 認証が行われた時刻
  • exp: トークンの有効期限
  • iat: トークンが発行された時刻
  • username: Cognitoで管理されているユーザーのID

正常系は確認できたので、今度は異常系について確認していきたいと思います。

実行コマンドと結果(異常系)

正常系は無事に確認できたので異常系もいくつか試していきたいと思います。

Authorizationヘッダーを空でリクエスト

Authorizationヘッダーを空でリクエストしてみます。

Authorizationヘッダーを空でリクエストのコマンドと実行結果
# 実行コマンド
curl http://localhost:3000 -i

# 実行結果
HTTP/1.1 401 Unauthorized
content-type: application/json
content-length: 42
Date: Sat, 28 Dec 2024 16:24:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"Authorization header missing"}

ステータスコード401でAuthorization header missingと返却されましたね。Middlewareの実装通りで期待値を満たしています。

不正なトークンを送信

トークンをaaaでリクエストしてみます。

不正なトークンを送信コマンドと実行結果
# 実行コマンド
curl http://localhost:3000 \
  -H "Authorization: aaa" -i

# 実行結果
HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 41
Date: Sat, 28 Dec 2024 16:28:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"Token not valid","detail":"JWT string does not consist of exactly 3 parts (header, payload, signature)"}

ステータスコードは403でToken not validと返却されましたね。詳細なエラーメッセージとしてはJWTとして文字列が成立していないと返却されていますね。

署名部を変更したトークンを送信

事前に取得したトークンの署名部の最後の箇所にaを追記したトークンを送信します。

署名部を変更したトークンを送信コマンドと実行結果
# 実行コマンド
curl http://localhost:3000 \
  -H "Authorization: <準備セクションで取得したAccess Token>a" -i

# 実行結果
HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 41
Date: Sat, 28 Dec 2024 16:28:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"Token not valid","detail":"Invalid signature"}

ステータスコードは403でToken not validと返却されましたね。詳細なエラーメッセージとしては有効な署名ではないと返却されました。

有効期限切れのトークンを送信

有効期限が切れたアクセストークンでリクエストしてみます。

有効期限切れのトークンを送信コマンドと実行結果
# 実行コマンド
curl http://localhost:3000 \
  -H "Authorization: <有効期限が切れたAccess Token>" -i
HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 80
Date: Sat, 28 Dec 2024 16:32:03 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"message":"Token not valid","detail":"Token expired at 2024-12-28T13:55:37.000Z"}

こちらもステータスコードは403でToken not validと返却されましたね。エラーメッセージはトークン有効期限切れを示しているメッセージが返却されました。

異常系もバッチリ検出してくれていますね!!

おわりに

aws-jwt-verifyはいかがだったでしょうか。ライブラリを活用することでトークンの検証もかなり楽にできますし、JWKSエンドポイントからの公開鍵取得のキャッシュやローテーション機能など自前で実装するのが少し手間な部分も作られていて嬉しいですね。

今回はHonoのMiddlewareとして検証機構を組み込みましたが、Hono以外でもCloudFront Lambda@Edgeや、API ゲートウェイ Lambda オーソライザー などバックエンドサーバーの手前でaws-jwt-veriffyを組み込んでトークン検証するのも手かと思いますし、Honoを使ったバックエンドサーバーで検証したい!となった際は少しでも参考になったら幸いです。

最後までご覧いただきありがとうございました!!

補足

下記アーキテクチャの場合は公式のレポジトリにも使い方や説明があるので、マッチするケースがある場合はご参照ください。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.